Repository navigation
feat(file-safety): guard machinery in the writer, with the identity and rights refusals (U5-B, split 1/2 of #1914, #1375) - #2357
Conversation
…ve (U1, issue 1375) Split unit U1 of PR 1833. Three changes, each with a test that fails without it: - a caller-supplied staging path is checked for location and file type before anything is written, so an arbitrary path or a symlink cannot be published onto the target; - a failed parent-directory fsync on POSIX is reported as PostCommitDurabilityError instead of being swallowed, so a successful return never claims durability the filesystem did not grant; - the staged file and this write's own staging directory are released before RollbackFailureError is thrown. Focused coverage for resolveLockKey added: canonical parent directory, a dangling-link chain, and termination at the bounded depth on a two-link cycle.
…ishTarget (U1, issue 1375) The resolver may fall back to the given path only when lstat also reports the path as absent. An EACCES or EIO failure says nothing about whether the path is a link, so falling back would publish through a link we were not allowed to inspect. Focused tests added for both branches.
… type-sound
compile failed at the unit head on three points:
- RollbackFailureError needs a string backupPath, but the throw now happens after cleanup, so the
`string | null` narrowing was lost. The failure is now held as { error, backupPath }.
- The async lstat stand-in is built on the Stats prototype so it satisfies fsSync.Stats.
- The realpath/readlink mocks are typed to the real signatures; the readlink mock answers once
because only the link path is read.
tsc clean, 50 tests pass, ESLint --max-warnings=0 clean, no suppression change.
The any usage this entry covered is gone in the rewritten file, so the count drops 4 -> 3. eslint --prune-suppressions --max-warnings=0 confirms it.
The read tools record the observed on-disk version through task.observationRegistry, but the field was only declared in a later unit, so at this head the call dereferences undefined and the mocked e2e run fails on the read_file smoke tests. The registry is introduced by this unit, so the field belongs here. tsc clean on this unit, 11 observationRegistry tests pass, ESLint --max-warnings=0 clean.
…rtion This unit was rebuilt from the pre-fix content source, so its copy of safeWriteText.ts still restored the backup over a write whose commit rename had already succeeded when the parent-directory fsync failed, and its spec asserted rmdir generically rather than against the staging directory this write created. Both are already settled in unit 1 (fws/u1-atomic-publish). Taking those files here keeps the shared code byte-identical across the units, so merging the chain in order does not overwrite unit 1's fix. Tests: 52 passed in safeWriteText.spec.ts.
…s notice The template literal continued on an indented source line, so the model-facing output carried four leading tabs before the content. Replace the indented newline with an explicit escape so the notice and the content are separated by a single newline and nothing else. Local note: readFileTool.spec.ts cannot run in this worktree (isbinaryfile is not resolvable from either node_modules here); CI covers it.
safeWriteJson rethrows RollbackFailureError and the telemetry callers record only error.message, so the generic wrapper message lost the filesystem errno text of the publish failure. Include the publish error message while keeping the rollback context (publishError, rollbackError, backupPath) unchanged. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
… rollback failure The assertion accepted any rmdir argument, so a regression that removed a different directory still passed. Read this write's own staging directory from fsSync.mkdirSync and assert that exact path. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
…the Actions queue, no source change
The dump path was the fixed sibling <target>.acl.tmp. A pre-existing user file at that path is unlinked by the failed-save branch and by both cleanup paths, and two concurrent writes to the same target share one dump, so one write can restore or delete the other's. Use the per-write unique name like the staging file. Tests: 52 passed in safeWriteText.spec.ts, ESLint clean with --max-warnings=0.
…eturned A slice that starts after line 1 and reaches EOF is not truncated, so the branch said "The file was read in full" even though the earlier lines were omitted. Name the slice start when the read did not begin at line 1, and add a focused test for that case. Local note: readFileTool.spec.ts cannot run in this worktree (isbinaryfile is not resolvable from either node_modules here) and ESLint cannot resolve its config here; CI covers both.
…opy backup) Pre-merge review flagged the publication path as non-atomic when backup:true: it renamed the existing target to a backup name and only then renamed the staged file into place, so the canonical path was absent for the whole commit window - readers saw a missing file, and a concurrent writer could create a new target that a later rollback would destroy. The backup is now a copy, never a move: - the destination is created with openSync(backupPath, "wx", 0o600) before any content exists at it, so no content ever sits at a path whose mode fs.copyFile chose (it uses the platform creation mask subject to umask on some platforms, the source's mode and read-only attribute on others); - chmod 0o600 after the copy clears a copied read-only attribute (Windows) that would otherwise make the fsync open fail with EACCES, and keeps a backup of a permissive file private; - the copy is fsynced, then deleted once the commit rename has published; nothing is ever renamed back. safeWriteText also rejects a staging file that is the target itself (same inode/device), which would otherwise let a caller-supplied tempPath silently alias the target. Tests: the safeWriteText spec is replaced with the copy-model spec plus a new integration spec that runs against the real filesystem (no fs mocks) - success publishes and leaves no residue, and a directory target is rejected with the payload bytes unchanged. The safeWriteJson spec moves from the three-rename/rollback model to the copy model, and the lock-key spec accounts for the extra lstat the aliasing guard performs. 83 passed / 1 skipped across safeWriteText.spec + integration spec + safeWriteJson + safeWriteJson.lockKey; tsc and eslint clean, no suppression drift.
Pre-merge review flagged that the write path trusts an unvalidated symlink target: safeWriteText resolves an existing link to its referent and safeWriteJson then merges and publishes onto that referent, so a repository that plants its project settings file as a link elsewhere receives the write at the linked path. safeWriteJson now accepts confineTo. The check runs on the resolved publish target, after resolvePublishTarget and before the merge read and before anything is staged, so a rejected write takes no lock and leaves no residue. Both sides are canonicalized the same way: the scope is resolved through symlinks (walking to the nearest existing ancestor when it does not exist yet, and rethrowing anything other than ENOENT), and the target is canonicalized with the same helper because a target that does not exist yet still carries the alias components of the path it was given. Callers that picked a path from a known scope pass that scope; a caller that does not declare one keeps the current behaviour. The MCP settings wiring that uses this is on the async-save series (Zoo-Code-Org#1403). Tests: an out-of-scope path is rejected on every platform with no lock and no staged file; a planted link escaping the project is rejected and the linked file keeps its bytes; a link inside the project still publishes to the referent; and a scope declared through a symlinked directory that does not exist yet is accepted.
The staging-identity guard compared the caller-staged file with the target through fs.lstat(targetPath).catch(() => null). Any error other than ENOENT - EACCES, ENOTDIR, ELOOP - was therefore indistinguishable from "there is no target", so a staging path that is a hard link of the target would pass the identity check, reach the commit rename, and let the failure handler unlink the only copy of the content. Only ENOENT now yields "no target"; anything else fails closed with a StagingPathError before anything is opened, staged or renamed. Test: the staging path resolves to a hard link of the target (same ino/dev) and the target lstat rejects with EACCES; the write is rejected with the comparison error and neither openSync nor rename is called.
The staging-identity guard read fs.Stats.ino/dev as JS numbers. On NTFS and ReFS those identifiers can exceed
Number.MAX_SAFE_INTEGER, and the rounding makes two different files look identical - a valid caller-staged file is
then rejected with StagingPathError (and a real alias could be missed). Both lstats now request { bigint: true } and
the comparison checks for bigint values before comparing them.
The spec stand-in carries bigint ino/dev, matching what lstat({ bigint: true }) returns at runtime (fs.BigIntStats is
a type-only export, so the double is documented at its single assertion).
The identity stand-in returns bigint identifiers regardless of the options, so the same-file tests could still pass if
either lstat dropped { bigint: true } - which is exactly the case that matters on NTFS/ReFS. Both tests now assert
that every identity lstat requested bigint stats.
The calls are filtered by their options rather than by path spelling: path.resolve prefixes a drive letter to
/tmp/... on Windows, so a path filter would only see one of the two reads on that platform.
…served On win32 the DACL of an existing target is saved before the commit rename so it can be reapplied afterwards. Two paths silently skipped that step and still published: - icacls /save failed (saved === false): the dump is cleaned up and the rename proceeds, so the new file inherits different access rights; - fs.access(targetPath) failed with something other than ENOENT (EACCES, ...): the catch treated "cannot check" as "target absent" and skipped DACL handling entirely. Both now report through a new onWarning sink (default console.warn): the write still proceeds - a missing or failing icacls must not leave the user unable to save, which is the documented fallback - but the caller is told the replacement may inherit different access rights instead of discovering it later. Tests: icacls save failure still commits the write and yields exactly one access-rights warning; an EACCES on the target yields the could-not-check warning and no icacls call. Verified as real regression tests - neutralizing the two warn calls makes both fail. 272 passed / 4 skipped locally; tsc and eslint clean.
onWarning is documented as the sink for non-fatal safety notices, but delivery was not isolated from the write: an onWarning callback that threw propagated to the outer failure handler before fs.rename, turning a non-fatal notice into a failed save. The warn binding now catches callback failures and logs them. The restore-failure notice is routed through the same binding so a caller supplying onWarning receives it. Same fix as the u6 unit, kept aligned across the series. Local: safeWriteText 58/58 green; eslint clean, no suppression growth.
proper-lockfile creates ${lockKey}.lock beside the lock key, and the key is the
symlink referent. A repository that plants .roo/mcp.json -> ~/.ssh/config
therefore made a confined write create a lock directory OUTSIDE the declared
scope, and when that directory was not writable the caller got a
lock-acquisition error after up to five retries instead of
ConfinedPathEscapeError. The confinement check now runs on the lock key before
acquireFileLock and is repeated on the resolved publish target inside the lock
(a peer writer may have moved the referent in between); both checks share
_assertWithinScope so they canonicalize identically.
Ported across the file-safety unit series so every unit that declares a scope
carries the same guarantee (same fix as fws/u3-observation-completeness).
Series alignment for the two review findings fixed on fws/u6-apply-patch-wiring: 1. safeWriteText's warn wrapper could not catch a rejection from an async onWarning sink - TypeScript accepts a value-returning callback where a void one is expected - so the rejected promise was left unhandled, which under Node's default mode can end the process after a write that already succeeded. The wrapper now attaches a catch handler without awaiting (awaiting would let warning delivery delay a committed write, or stall it on a hung sink) and reports the rejection through the fallback sink. 2. safeWriteJson created the target's parent directory BEFORE the preflight confinement check, so a confined write to an out-of-scope path with a missing parent still created a directory outside confineTo. resolveLockKey and the check need no directory to exist, so the order is now lock key, confinement, mkdir; the in-lock check on the resolved publish target stays.
…ith doUnmock - safeWriteText.integration.spec: the backup:true case bails out in the backup step (copyfile over a directory), so the commit rename never runs and a regression in commit-failure cleanup would pass. Added the backup:false case, which is the shape that reaches Step 4: the rename of the staging file over the target directory fails and the staging file must be cleaned up. - safeWriteJson.test: the finally blocks removed a vi.doMock registration with vi.unmock, which vitest hoists to the top of the file, so the cleanup did not undo the registration where it was written. Both sites now use vi.doUnmock, the non-hoisted counterpart. Verified the two paths are distinct by calling safeWriteText against a real directory target: backup:true fails at copyfile, backup:false fails at rename of the staging file. Local: 29 passed / 4 skipped across the two specs; eslint clean on both files.
Task.ts initializes the registry per Task, but nothing read it off a real Task: the registry tests exercise standalone instances and ReadFileTool's spec injects its own double, so the wiring itself was unverified - a Task that never built a registry, or shared one across Tasks, would pass every existing test while silently dropping read observations. The new test constructs a real Task and asserts observationRegistry is an ObservationRegistry, that a second Task gets a different instance, and that an observation recorded on one Task is invisible to the other (parent and subtask authority over a file must stay separate). Local: Task.dispose.test.ts 15 passed; eslint clean; no new tsc diagnostics in this file.
Security Boundaries: safeWriteJson resolves the publish target through realpath before staging beside it, so a repository that ships .roo/mcp.json as a symlink to a file OUTSIDE the workspace has that outside file replaced as soon as the user edits a project MCP setting, deletes a server, or toggles tool always-allow. The three project-capable write sites passed no confinement option, and the base implementation renamed the link itself, so the external write is introduced here. confineForMcpWrite(source) returns the provider cwd (falling back to getWorkspacePath) for project writes and undefined for global ones - global settings live in the user's own settings directory, which is deliberately not confined. All three sites now pass it, so the write fails closed with ConfinedPathEscapeError instead of touching the referent. This is the same change as 256091d on fws/u3-observation-completeness (Zoo-Code-Org#1912); the unit branches are not cumulative, so every branch that carries the writer needs its own copy. Regression Evidence: the per-Task registry now fails closed on teardown too - disposeOnce() clears it, so a disposed Task still reachable through a parent/subtask reference cannot hand a token captured before teardown to a guarded write. Two McpHub tests pin the call sites (a project-scoped allowlist write carries confineTo = workspace root, a global one does not) and a Task.dispose test pins the clear. The two-real-Task registry test added in 87986e9 already covers the distinctness the checklist row asked for. Local: vitest McpHub + Task.dispose 89 passed. Negative controls: helper mutated to return undefined -> project confine test fails; clear() commented out -> disposal test fails; both restore green. tsc --noEmit 50 errors before and after the change (unchanged branch baseline). eslint 0 errors / 0 warnings on all four files, no suppression entry added or increased.
…every MCP confine site Lifecycle Resource Cleanup: disposeOnce() cleared the registry, but a read that was already awaiting fs.readFile when disposal began resumed afterwards and recorded the file again - the disposed Task was authoritative once more, and it kept doing the new post-read stat work for a turn that will never be acted on. ObservationRegistry.close() now drops the entries AND refuses later observations, Task.disposeOnce() calls close(), and both ReadFileTool paths skip the post-read stat + observe work when task.abort is set (checked before the awaited stat, not after it). Regression Evidence: the confinement fix touched three call sites but only toggleToolAlwaysAllow was pinned. updateServerTimeout and deleteServer now have a project case (confineTo = workspace root) and a global case (confineTo undefined) each; the connection double is shared at describe scope so all four sites use one typed fixture. safeWriteJson is mocked in this file, so a lower-level writer test cannot see a missing MCP argument - each call site has to be pinned here. Tests: 2 closure tests (close drops entries and reports closed; observe is a no-op after close), 2 aborted-task tests (native and legacy path record nothing), 4 call-site confinement tests, and the disposal test extended to assert the registry stays empty when a late observation arrives. Local: 203 passed across readFileTool, McpHub, Task.dispose and observationRegistry. Negative controls, each mutate -> test fails -> restore -> passes: ReadFileTool guard neutered, ObservationRegistry closure guard neutered (fails both the registry spec and the Task disposal test), and close() downgraded to clear() (fails the disposal test). tsc --noEmit 50 errors before and after (unchanged branch baseline, 0 in touched files). eslint 0 errors / 0 warnings across all 8 touched files; no suppression entry changed.
The abort guard sat before the post-read fs.stat, so a cancellation that lands while that stat is in flight still recorded an observation - for a run that will never act on the read, and into a registry that disposal has already retired. Both paths now re-check task.abort after the await and before observe(); the read itself still answers the model. Two tests flip task.abort inside the pending post-read stat, one per path, and assert observe() was never called and the registry stayed empty.
|
Superseding width note for head The arm's line, verbatim, from the gate's own Beside the counted lines, the shape a reviewer needs with it: 26 files, +6,995 −76 (GitHub's own numbers for this head), of which 17 delta entries are That is the width the split plan on #2350 partitions, unit by unit, against each unit's own merge base: six units at 448 / 88 / 131 / 27 / 25 / 51 counted executable lines, summing to exactly 770. Nothing has been opened; the plan and the verification status live in the comment this note points to. |
|
Split plan pointer. The six-unit split plan for the 2357 head — refs, boundaries, per-unit counted executable lines (448 / 88 / 131 / 27 / 25 / 51, summing to exactly 770), what has been verified alone, the verification disclosure about Nothing has been opened for this plan from #2357. The scope line for the current 2357 head |
|
@coderabbitai review |
Action not completed
|
…er (U5c-7, split of Zoo-Code-Org#1991) ObservationRegistry records per-file observation state with a completeness flag, refuses further recording once closed, and is dropped when a task is disposed. It lands together with the Task-side code that imports it, because the repo's own policy asserts that a production file imported only by tests is still reported as unused (scripts/knip-policy.test.mjs) and knip --production is a required check. 384 additions / 0 deletions across 5 paths. The mutation gate counts 25 of 500 executable lines and 15 of 400 selectors at this base. Content ported byte-identical from Zoo-Code-Org#2357 (5319fe9..d6fa711).
…er (U5c-7, split of Zoo-Code-Org#1991) ObservationRegistry records per-file observation state with a completeness flag, refuses further recording once closed, and is dropped when a task is disposed. It lands together with the Task-side code that imports it, because the repo's own policy asserts that a production file imported only by tests is still reported as unused (scripts/knip-policy.test.mjs) and knip --production is a required check. 384 additions / 0 deletions across 5 paths. The mutation gate counts 25 of 500 executable lines and 15 of 400 selectors at this base. Content ported byte-identical from Zoo-Code-Org#2357 (5319fe9..d6fa711).
…very rejects readFileWithMissingRetry awaited recoverMissingJsonFile inside its retry catch but outside any handler, so a rejection from the recovery escaped the function instead of becoming the value that branch interprets. Every caller classifies this read by error code: apiMessages.ts maps ENOENT to "no file yet" and anything else to io_error, taskMessages.ts maps it to the "not_found" kind, TaskHistoryStore.ts swallows it into null. A rejecting recovery therefore re-classifies the read: an EBUSY inside recovery becomes an I/O error on a file that is merely absent. The fix keeps the missing-file retry the rows already assert and counts a rejected recovery as "nothing recovered", so the caller always receives the ENOENT read failure it knows how to classify. jsonFileRecovery traces the recovery failure with its own code before throwing, so the diagnostic is not lost, and no new abstraction is introduced. safeWriteJson.ts:343 is a changed executable line in this pull's own diff: the hunk switches its argument from absoluteFilePath to resolvedTargetPath, and no row distinguished the two spellings because every existing recovery row keeps the alias and the referent in one directory. The added row plants the orphaned backup beside the referent with the alias directory empty, which only the resolved-target spelling can see, and asserts that the merge callback read the restored history, that the trace reported the restore, that the published file holds the merged content, and that the backup was consumed. The link is simulated by mocking fs.realpath, the same way the staging and lock-key rows in that file do, because real symlinks are unavailable here. Controls, holding the previous head d6fa711: - new retry row against the previous head with the source untouched: 1 failed | 3 passed, the failure being the escaping EBUSY rejection; with the fix: 4 passed. - the same row against each mutant the instrument plans for lines 54-55 of the fix: a recovery rejection counted as recovered (.catch(() => true)) makes the reader resolve with bytes that appeared during the recovery and fails this row alone; reverting line 49 to its pre-fix shape fails this row alone. - new recovery row with 343 reverted to its pre-pull argument: the only failure in utils/__tests__/safeWriteJson.test.ts (1 failed | 53 passed | 4 skipped), so the row reaches 343 and asserts exactly the change this pull made there. - repo-wide prettier --check .: clean. tsc --noEmit in src: clean. eslint on the three touched files with --max-warnings=0 --prune-suppressions: no problems, and src/eslint-suppressions.json is byte-identical to its committed blob (a prune run on a partial file set rewrites that file, so it was reverted). Pricing, printed by scripts/stryker-diff.mjs (blob c332971815aecfaf) against this pull's own base 5319fe9: the instrument reports "extension has 770 changed executable lines (limit 500)" at the previous head and 772 here, so this commit adds two chargeable executable lines to a package already 270 lines over the cap, and 2357 stays a split question - this fix does not pretend otherwise. On its own unit delta the instrument lists only core/task-persistence/readFileWithMissingRetry.ts:54-55 (2 chargeable lines) and its dry run instrumented that file with 5 mutants (limit 400): the two added rows are test-only and the instrument's own path classifier excludes __tests__ paths, so the rows are cap-neutral by construction rather than by headroom. Review thread answered: pull-request comment 4505288199, on src/utils/safeWriteJson.ts line 343. The await above has no review thread to answer: the thread listing read from this repository tonight lists 50 threads, every one of them resolved, and none on src/core/task-persistence/readFileWithMissingRetry.ts. No changeset and no changelog entry.
|
This pull cannot be split into two units that clear both caps. The 500-line cap is clearable on The axis is forced by the import graph, not chosen. Measured at Four shapes, all against main
The cut loses nothing and floats nothing: 448 + 299 = 747, the parent's own price, and 425 + 362 = 787, One path brings the pull under both caps: Every mutant figure above is an instrumented count, not a verdict. The local preflight cannot Conflict against main is exactly two paths: One question, and it is the reviewer's: do you grant a one-path exclusion for |
Reply to comment 6812390131 - the answer to the one question that comment put, carried by ameasurement taken now The question, as that comment stated it: grant the one-path exclusion for Measured now, at the footing the gate printsInstrument: scripts/stryker-diff.mjs taken from commit 9811691 by Pull 2357 at head 9c24106 against its own merge base The same head with src/services/file-safety/safeWriteText.ts (448 lines, 130 selector ranges) Main's tip fetched today is efc22ed, a descendant of that merge What those two numbers settle, and what they do notThe line relief is not portable: 772 minus 131 is 641, so removing the largest chargeable path other It is a line-cap measurement only. No instrumented mutant pass ran at this footing, so nothing here The mutant figures that exist for this pull come from synthetic shapes rebased onto efc22ed and The two footings differ by 25 lines on both sides - 772 against 747 whole, 324 against 299 residual. Legs that did not run, named beside the figures that do not rest on themNo instrumented mutant pass at this head's own footing: the 772 and 324 above are Posture read on the free door at the same momentGET /pulls/2357 (HTTP 200, 25,204 bytes, sha256-16 05cb576dc6aa36dc): state open, mergeable false, GET /pulls/2357/reviews?per_page=100 (HTTP 200, 263,073 bytes, ed6ee31afbeda6b3): 100 rows on page GET /issues/2357/comments?since=2026-11-12T02:15:04Z (HTTP 200, 7,663 bytes, b86d5fb2f1cb7a53): 1 What would change the answerA mutant count measured at this head's own footing, which needs a Stryker leg this host cannot |
Split unit 1/2 of #1914. The plan is on #1991; this branch's parent is PR #1913's head
416179d17de8, so merge #1913 first and this branch adds exactly its own delta on top (measured: 191 changed executable lines underscripts/stryker-diff.mjsrules at pinned node v22.23.4; the cumulative figure a base-mainPR prints is 638, because the gate measures against merge-base with the PR base and #1913 is not in main yet).Contents.
src/services/file-safety/safeWriteText.ts+ spec,src/utils/fileLock.ts+ spec,src/core/task/Task.ts+ spec, and oneknip.jsonline: 7 files, 1637 added+deleted lines.guardedWrite.tsand its spec are in unit 2, because the tool consumes this unit's guard API rather than the reverse.Two refusals are here because the refresh merge made the two copies of
safeWriteText.tsdiverge, and the divergence had no consumer-side signal.target itselfandDaclInspectionErroreach match 0 lines tree-wide at33e9200900bb, so under guard-core's shape their absence would have read as agreement.(dev, ino)read with bigint stats (file identities on NTFS/ReFS exceedNumber.MAX_SAFE_INTEGER); onlyENOENTmay skip the comparison -EACCES/ELOOP/ENOTDIRmean the comparison could not be made and is itself a refusal. Without it an alias reaches the commit and the failure handler unlinks the only remaining copy of the content. Recorded as a requirement on safeWriteText's staging-name guard and jsonFileRecovery's leftover prefixes are mutually exclusive #2350.icacls /savefails, including after writing a partial dump - the dump is still unlinked) or whose access rights cannot be read yieldsDaclInspectionErrorwithphaseandtargetPathbefore anything commits, rather than a warning beside a committed file.Tests moved with the contract, including two that asserted the opposite. Replaced:
win32 DACL failure falls back to plain rename (never fails the write)and the partial-dump test's assertion that the write commits. Added: the failed-save refusal, the inspection refusal, the alias refusal, and the non-ENOENTrefusal.safeWriteText.spec.tsis 74/74; the inspection test injectsplatform: "win32"and stubsicacls, so it runs on either hosted leg and the hosted windows leg passes this file.The
knip.jsonline, and exactly what it hides. knip --production does not credit imports from files knip itself reports as unused, andguardedWrite.tshas no production importer (3 non-test matches: the definition plus two doc mentions), so the guard primitive arrives one unit before its consumer. Without the line this unit failsknip --productionwith exactly one finding -Unused exports (1): safeWriteText function src/services/file-safety/safeWriteText.ts:439:23- and plain knip passes either way. With the line, both modes and the 8 knip-policy tests pass. It is not a blanket: unit 2 replaces this entry withcore/tools/guardedWrite.ts!rather than adding to it, and unit 2 without its own line reports two findings (Unused files (1): src/core/tools/guardedWrite.tsplus the same unused export), so the entries cover named findings and do not accumulate. This matters becauseknipis one of the six required contexts on main (check-translations, bothplatform-unit-testlegs,compile,knip,e2e-mock), while the 500-line mutation gate is advisory.Local gates on this head: prettier, eslint
--max-warnings=0,tsc -p src/tsconfig.json --noEmit, knip, knip --production, knip-policy 8/8 - all clean. Vitest: misc 120 files / 2125 tests passed; core 197 files / 4282 passed (one file fewer than the merge head,guardedWrite.spec.tsbeing unit 2's); services 82 files / 1686 passed with the one pre-existing environmental red (services/rules, 5 failures, allEPERM: operation not permitted, symlink, a file this unit does not touch).What this push is also measuring.
74f5eb287d1bfailed the hosted windows leg onpnpm run test:misc, 3 failures inutils/__tests__/safeWriteJson.test.ts > safeWriteJson > should preserve backups(expected … [{ committed: true }, {}], received[{ stale: true }, {}]; andpromise resolved "undefined" instead of rejecting).safeWriteJson.tsand its test are byte-identical between416179d17de8(windows-green) and74f5eb287d1b(windows-red). This head carries the writer and lock changes but none ofguardedWrite.ts, so the windows result here tells us which layer flips the recovery path; the read is being reported to the lead either way, and nobody is repairing the recovery semantics until that read lands.